主题
Go 模块管理:弄清这次构建到底用了哪份代码
同事改了一个依赖,你的程序为什么也变了?本地联调通过,CI 为什么找不到包?这些问题通常要同时看三件事:import 指向哪个模块、依赖图选中了哪个版本、主模块或工作区有没有把代码替换掉。
本文沿着一次构建把它们连起来。示例固定 Go 1.27.2、Linux/amd64;公开依赖固定 golang.org/x/text v0.28.0,其余实验用本地小模块,不需要私有仓库账号。实际输出和完整目录都在文中,私有仓库访问只解释规则,不冒充已经登录验证。
先分清四个名字
| 名称 | 它回答的问题 | 例子 |
|---|---|---|
| 包 package | 哪组同目录源码一起编译、以什么路径导入? | golang.org/x/text/unicode/norm |
| 模块 module | 哪些包一起发布和版本化? | golang.org/x/text |
| 模块版本 | 这次取哪份发布内容? | v0.28.0 |
| 主模块 main module | 当前命令以谁的配置组织构建? | 示例自己的 go.mod 所在模块;工作区可有多个 |
go.mod 声明模块路径,包的导入路径通常是“模块路径 + 包子目录”。因此不必每建一个包就执行一次 go mod init。多放一个 go.mod 会新建模块边界,同时带来独立的版本和依赖维护责任。模块与包
本文的建议是先用一个模块。只有代码确实需要独立发布、独立依赖约束,或者正在联调多个已有模块,才把模块拆开。
go.mod 选依赖,go.sum 留校验依据
先看一个真正用到外部模块的调用:
go
input := "e\u0301"
output := norm.NFC.String(input)
fmt.Printf("before=%d after=%d equal=%v\n", len(input), len(output), output == "é")text
before=3 after=2 equal=true这里调用固定版本的 Unicode NFC 规范化 API,将 e 和组合重音规范化为合成字符;字节长度变化来自此输入的 UTF-8 编码,不能推广成“规范化一定缩短字符串”。norm v0.28.0
对应 go.mod 只需明确模块路径、Go 版本和依赖:
go.mod
module example.com/v2/moduletext
go 1.27.2
require golang.org/x/text v0.28.0go 行是模块要求的最低 Go 版本,并影响相应语言版本规则;它与“我的电脑碰巧装了哪个编译器”不同。toolchain 行可以为主模块建议工具链,而 GOTOOLCHAIN=local 会阻止自动下载和切换。本次实验固定本地工具链,避免验证时版本悄悄改变。go 指令、工具链选择
第一次获取固定依赖后,go.sum 中能看到两类记录:
text
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=前者校验模块内容,后者校验该版本的 go.mod。go.sum 不是“只准用这些版本”的完整锁文件:它可保留多个版本或只读过元数据的版本,真正的版本选择仍由依赖图决定。建议把两份文件一同提交,审查依赖变化时同时看它们。内容校验
| 要做的事 | 常用命令 | 要看什么 |
|---|---|---|
| 初始化模块 | go mod init example.com/app | 模块路径是否稳定、能否对应发布位置 |
| 升级到明确版本 | go get example.com/lib@v1.4.2 | 不仅直接依赖,传递依赖是否也变了 |
| 同步 imports 与依赖声明 | go mod tidy | 新增和移除的依赖是否符合预期 |
| 看最终版本 | go list -m all | 版本、替换路径;vendor 模式需显式 -mod=mod |
| 看谁依赖谁 | go mod graph | 每条边要求的版本,不等于每条边各装一份 |
| 查引入原因 | go mod why -m 模块路径 | 从主模块包到该依赖的路径 |
| 校验缓存 | go mod verify | 缓存中的模块是否被修改,不是漏洞扫描 |
这些命令的职责来自 go mod 命令参考。本文实测先下载并校验,普通运行与 vendor 运行均输出同一行规范化结果;go mod verify 输出 all modules verified。这只证明本次缓存一致,不能证明依赖没有恶意逻辑或已知漏洞。
“最小版本选择”为什么选出了较高版本
假设主模块直接要求 shared v1.1.0,alpha 要求 v1.2.0,beta 要求 v1.4.0。所有路径都叫 example.com/shared,一次构建不会给不同调用方分别塞一份不同版本。
正在绘制图示…
查看图源代码
flowchart LR
App["主模块"] -->|"require v1.0.0"| A["alpha"]
App -->|"require v1.0.0"| B["beta"]
App -->|"至少 v1.1.0"| S["shared:选中 v1.4.0"]
A -->|"至少 v1.2.0"| S
B -->|"至少 v1.4.0"| S
MVS(Minimal Version Selection)在依赖图提出的最低要求中,为同一模块路径选取最高的要求版本;它不会为了“新”而自动去选仓库里最新的 v1.9.0。这里的“最小”是满足当前图的要求,不是选择数字最小的 v1.1.0。MVS 规则、原始设计
附录创建三份本地依赖及主模块,用 replace 提供内容,再执行:
bash
go mod edit -require=example.com/shared@v1.1.0
go list -mod=mod -m all实际结果:
text
example.com/mvsapp
example.com/alpha v1.0.0 => ../alpha
example.com/beta v1.0.0 => ../beta
example.com/shared v1.4.0 => ../shared-mod=mod 允许命令更新主模块中的依赖记录;本例随后 go mod graph 里主模块的 shared 边也已变成 v1.4.0。只查看而不允许改动时,保持文件已同步并用默认只读行为,遇到“updates to go.mod needed”先检查差异,不要把它理解为下载失败。
版本号表达的是兼容承诺
| 变化 | 常见版本变化 | 对调用方的含义 |
|---|---|---|
| 兼容修复 | v1.2.3 → v1.2.4 | API 兼容,仍需回归测试 |
| 兼容新增功能 | v1.2.3 → v1.3.0 | 旧调用应继续成立 |
| 不兼容公开 API 变化 | v1 → v2 | 模块通常改成 example.com/lib/v2,imports 也改 |
| 未稳定发布 | v0.x 或预发布后缀 | 不应假设与此前版本兼容 |
| 未打 tag 的提交 | pseudo-version | 由工具将提交映射成可排序版本,不手工拼时间戳 |
这是语义化版本的约定和 Go 的主版本路径规则,不是编译器替发布者证明兼容。/v2 让新旧大版本成为不同模块路径,因此可在同一构建中共存;gopkg.in 等有专门的路径规则,不能把 /v2 口诀无条件套到所有历史仓库。SemVer 2.0.0、主版本后缀与例外
replace 和 go work:方便联调,也会隐藏缺失的发布依赖
replace example.com/shared => ../shared 只改变代码来源,不会自动把这个模块加进依赖图,还需要 require 或其他依赖路径。依赖模块自己写的 replace 不会传染给上游调用者;有效的是主模块及工作区的替换规则。换句话说,别人不能靠发布一份 go.mod 悄悄替你重定向依赖。replace
| 联调办法 | 适合情形 | 容易遗漏的事 |
|---|---|---|
| 主模块写本地 replace | 只想在一个应用里临时替换一份依赖 | 本机相对目录在 CI 不存在;发布前清理或正式版本化 |
| go.work 的 use | 多个模块要同时作为主模块开发 | 其他模块的本地内容可能掩盖依赖声明错误 |
| go.work 的 replace | 工作区统一替换依赖 | 工作区级规则影响参与构建的模块,要审清范围 |
在本例中,app 声明依赖 greeting v0.1.0,却没有下载这个发布版本。go.work 同时 use app 和 greeting,于是运行 app 得到:
text
local greeting同一目录切换到 GOWORK=off GOPROXY=off go run .,实际失败:
text
main.go:4:2: missing go.sum entry for module providing package example.com/greeting ...错误省略号只省去了重复模块路径,原始输出保留在配套日志中。这个失败表明 app 尚不能凭自身声明在本例离线环境独立构建,不是说明 greeting 的远端一定不存在。工作区运行成功也不等于已发布的依赖可用。工作区规则
建议在发布模块的 CI 中再做一遍 GOWORK=off go test ./...,检查独立构建。go.work 是否入库要按仓库用途决定:统一开发的多模块仓库可以共享工作区;供外部使用的模块更应防止 CI 意外依赖开发者本地配置。go env GOWORK 能先回答“当前究竟启用了哪份工作区”。工作区与 CI 的官方提醒
私有模块:先决定请求该发给谁
GOPROXY 控制从代理还是版本控制系统取模块。逗号分隔时,前一个代理通常只有返回 404/410 才继续回退;竖线分隔允许在任意错误后继续。把任意错误都当成“再问公共服务”,可能让原本只应留在公司内部的模块路径外泄。代理回退规则
GOPRIVATE 用模块路径前缀的 glob 模式说明哪些模块是私有的;默认同时影响“不经公共代理”的 GONOPROXY 与“不查公共校验数据库”的 GONOSUMDB。它不提供 Git 账号、证书或访问授权。私有模块
bash
# 说明性配置:example.corp 为占位域名,本文没有访问它。
GOPRIVATE='example.corp/*' go mod download若公司代理同时提供私有和公共依赖,可能要专门配置 GONOPROXY 让私有模块仍走该代理。这应按公司网络和凭据政策设置,不能从一个博客复制到所有环境。排错时可先查看 go env GOPROXY GOPRIVATE GONOPROXY GONOSUMDB;不要把含凭据的代理 URL 或诊断输出直接贴进公开工单。
公共模块的 checksum database 帮助核对同一模块版本的内容一致性;它并不审计依赖的行为。为了省一次配置就永久关闭 GOSUMDB,会损失一层完整性检查。本文对真实公开依赖显式使用官方代理与校验服务,纯本地示例则可离线完成。校验数据库
vendor:把构建所需依赖带进仓库
go mod vendor 将构建和测试主模块所需的依赖包复制到 vendor,并生成 vendor/modules.txt。它不是把整个模块缓存完整备份;依赖自己的测试包等内容不会一概保留。go 版本和目录满足条件时会自动使用 vendor,本例用 go run -mod=vendor . 把选择写明。vendor 命令及一致性规则
收益是依赖源码可随仓库审查,构建可少依赖下载链路;代价是仓库体积和同步成本。不要手改 vendor 后以为 go.sum 会自动认证这些改动,也不要把“有 vendor”当成所有工具、构建脚本和系统依赖都离线可用。
本例重复运行时遇到过一个直接体现规则的错误:存在 vendor 后,go list -m all 不能从 vendor 算出完整模块图。改成 go list -mod=mod -m all 即可查看模块图;真正要验证 vendor 构建时仍用 -mod=vendor。这是显式选择不同工作模式,不需要删除整个模块缓存。
资料与验证边界
- Go Modules Reference:本文模块、MVS、工作区、代理、校验和 vendor 的主要契约依据;实验同时读取 Go 1.27.2 的
go help mod与 cmd/go 文档。 - 工具链选择、SemVer、MVS 原始设计。
- x/text v0.28.0 norm:唯一业务示例外部包,版本固定且执行模块校验。
2026-10-13,Go 1.27.2、Linux/amd64 实际执行公开模块下载与校验、普通及 vendor 运行、工作区成功/禁用工作区失败、MVS 选版。没有执行私有仓库访问、线上发布或企业代理配置。目录中的 MVS 模块是教学用本地内容,replace 不证明对应远端 tag 存在。
完整复现实验
将下面文件按路径保存,工作目录设为包含 run_modules.sh 的目录。使用 Go 1.27.2,设置 GOTOOLCHAIN=local 和 GOFLAGS=-buildvcs=false;执行 bash run_modules.sh。脚本中的公开下载需要网络;工作区失败用例是预期结果,脚本会检查它确实失败。vendor 是脚本生成物,无需手工复制其源码。
run_modules.sh
bash
#!/usr/bin/env bash
set -eu
cd "$(dirname "$0")/modules"
printf '\n== pinned public module ==\n'
cd text
GOPROXY=https://proxy.golang.org GOSUMDB=sum.golang.org go mod download all
GOPROXY=https://proxy.golang.org GOSUMDB=sum.golang.org go mod verify
go run .
go list -mod=mod -m all
go mod vendor
go run -mod=vendor .
cd ../work/app
printf '\n== workspace ==\n'
go env GOWORK
go run .
printf '\n== same app without workspace: expected failure ==\n'
if GOWORK=off GOPROXY=off go run .; then exit 1; else echo "expected_failure=true"; fi
cd ../../mvs/app
printf '\n== MVS selected shared version ==\n'
go mod edit -require=example.com/shared@v1.1.0
go list -mod=mod -m all
go mod graphmodules/mvs/alpha/go.mod
go.mod
module example.com/alpha
go 1.27.2
require example.com/shared v1.2.0modules/mvs/app/go.mod
go.mod
module example.com/mvsapp
go 1.27.2
require (
example.com/alpha v1.0.0
example.com/beta v1.0.0
example.com/shared v1.4.0
)
replace example.com/alpha => ../alpha
replace example.com/beta => ../beta
replace example.com/shared => ../sharedmodules/mvs/beta/go.mod
go.mod
module example.com/beta
go 1.27.2
require example.com/shared v1.4.0modules/mvs/shared/go.mod
go.mod
module example.com/shared
go 1.27.2modules/text/go.mod
go.mod
module example.com/v2/moduletext
go 1.27.2
require golang.org/x/text v0.28.0modules/text/go.sum
text
golang.org/x/mod v0.26.0 h1:EGMPT//Ezu+ylkCijjPc+f4Aih7sZvaAr+O3EHBxvZg=
golang.org/x/mod v0.26.0/go.mod h1:/j6NAhSk8iQ723BGAUyoAcn7SlD7s15Dp9Nd/SfeaFQ=
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
golang.org/x/tools v0.35.0 h1:mBffYraMEf7aa0sB+NuKnuCy8qI/9Bughn8dC2Gu5r0=
golang.org/x/tools v0.35.0/go.mod h1:NKdj5HkL/73byiZSJjqJgKn3ep7KjFkBOkR/Hps3VPw=modules/text/main.go
go
package main
import (
"fmt"
"golang.org/x/text/unicode/norm"
)
func main() {
input := "e\u0301"
output := norm.NFC.String(input)
fmt.Printf("before=%d after=%d equal=%v\n", len(input), len(output), output == "é")
}modules/work/app/go.mod
go.mod
module example.com/workapp
go 1.27.2
require example.com/greeting v0.1.0modules/work/app/main.go
go
package main
import (
"example.com/greeting"
"fmt"
)
func main() { fmt.Println(greeting.Message()) }modules/work/go.work
text
go 1.27.2
use (
./app
./greeting
)modules/work/greeting/go.mod
go.mod
module example.com/greeting
go 1.27.2modules/work/greeting/greeting.go
go
package greeting
func Message() string { return "local greeting" }